3.4 内存模型与并发安全

内存模型是并发里面很重要的一个东西,涉及了共享、竞争数据等问题。

在本节我们将从最基本的概念对内存模型及并发安全进行一个剖析。

本节代码存放目录为 lesson10

内存模型概述

什么是内存模型?

在很多文章中对于内存模型的描述都很抽象,看起来不知道说的是什么,大多都是直译过来的。

准确的说,内存模型指的是:一组规则或规范

这些规则的作用是:定义了在并发环境中,不同线程或协程对共享内存的操作顺序、可见性和一致性。

它们确保了在多线程或多协程程序中,内存操作的行为是可预测和可靠的。

所以如果内存模型这几个字不好理解的话,我们记为并发规则或者并发架构也不是不可以。


Go 的内存模型

Go语言的内存模型定义了一组规则,确保多个goroutine之间的内存操作在并发环境中是可预测和一致的。

虽然Go的内存模型与C++Java的内存模型有一些相似之处,但它根据Go语言的特性进行了独立设计。

Go的内存模型主要涵盖了以下几个方面的规则:数据竞争、内存屏障、内存一致性等。我们将分别对这些概念进行讲解。

数据竞争与并发安全

什么是数据竞争?

数据竞争在并发系统中是绕不开的议题,它指的是:多个协程对同一个变量同时进行读写操作,同时也没有采用任何同步机制或者锁,最终导致变量的值不可预测。

我们通过下面的代码来看一下:

var (
    counter int
)

func increment() {
    for i := 0; i < 10000; i++ {
        counter++
    }
}

func main() {
    go increment()
    go increment()

    time.Sleep(time.Second)
    fmt.Println("Counter: ", counter)
}

执行结果如下所示:

Counter:  12983

从上面的结果输出我们可以看出,运行结果并不是20000,再运行几次会发现每次结果都是不一样的。

这就是发生了数据竞争,那么为什么会发生呢?我们以下面的示意简单理一下:

协程1运行:读取 counter = 0
协程1运行:counter + 1 = 1

协程2运行:读取 counter = 1 同时协程1运行:读取 counter = 1
协程2运行:counter + 1 = 2  同时协程1运行:counter + 1 = 2

在上面的示意中,此时协程1运行了2次,协程2运行了1次,按理来说这时候counter应该是3了,但是实际结果是2

本质就是由于两个协程读取到的值都是一样的1,两个协程在执行的时候也都是对1 执行加 1,那最后结果自然就变成了2

数据竞争的危害还是挺大的,在业务系统中可能会导致我们的结果变得很奇怪,同时还可能导致程序崩溃。


如何避免数据竞争?

想要避免数据竞争,就需要掌握竞争发生的条件,那就是多个同时操作一个。

那么我们只需要改为只让一个协程读取就可以解决这个问题,在Go语言里面有锁、通道、原子操作等方式。

使用锁

Go语言中我们可以使用sync.Mutex来进行加锁处理,保证在同一时间只有一个协程可以访问这个变量。如下代码所示:

var (
    counterLock int
    mu          sync.Mutex
)

func incrementLock() {
    mu.Lock()
    for i := 0; i < 10000; i++ {
        counterLock++
    }
    mu.Unlock()
}

go incrementLock()
go incrementLock()

time.Sleep(time.Second)
fmt.Println("CounterLock: ", counterLock)

结果输出如下所示:

CounterLock:  20000

在上面的代码中,我们使用sync.MutexcounterLock进行了加锁,结果输出符合我们的预期。

加锁的原理可以理解为:让第一个协程访问时,就先定义个状态,其他协程来访问的时候就等待,直到前面的协程处理完为止。


使用通道

通道channel我们可以理解为就是一个消息队列,那么就算有N个协程在同时写,也只会有一个出口,也就是将N变为1。如下代码所示:

func incrementChan(ch chan int) {
    for i := 0; i < 10000; i++ {
        ch <- 1
    }
}

var (
    counterChan int
)
ch := make(chan int, 3)
go incrementChan(ch)
go incrementChan(ch)

go func() {
    for value := range ch {
        counterChan += value
    }
}()
fmt.Println("CounterChan: ", counterLock)

结果输出如下所示:

CounterChan:  20000

在上面的代码中,我们使用通道进行了数据的发送,由一个协程去读写变量就可以,其他的协程只负责向通道里面写数据。

这样就是将多个读写改为了只有一个读写,那么就不可能存在竞争的问题。


使用原子操作

对于简单的整数递增或递减操作,我们可以使用sync/atomic包提供的原子操作来确保线程安全。如下代码所示:

var (
    counterAtomic int32
)

func incrementAtomic() {
    for i := 0; i < 10000; i++ {
        atomic.AddInt32(&counterAtomic, 1)
    }
}

go incrementAtomic()
go incrementAtomic()
time.Sleep(time.Second)
fmt.Println("CounterAtomic: ", counterAtomic)

结果输出如下所示:

CounterAtomic:  20000

在上面的代码中,我们使用到了atomic.AddInt32,这是Go语言官方为我们封装好的原子操作,对于一些简单的操作我们可以直接使用这种方法即可。

内存屏障与一致性

什么是内存屏障?

在机器的多核处理器下,为了提高性能,处理器可能会对执行指令的顺序进行优化,这种优化就可能将指令进行重新排序,也就是指令重排。

指令重排可能导致指令在内存中的实际执行顺序与代码定义的顺序不一致。在单线程环境中,这通常不会引发问题,但在多线程环境中,这种重排序就可能导致数据竞争内存可见性问题。

比如:对于a变量的读写操作,本身应该是先读后写,但是由于重排就可能出现先写后读的情况,从而导致逻辑错误或数据不一致。

为了解决这个问题,引入了内存屏障,或者说内存栅栏。它其实是一个指令,用于强制某些内存操作按照指定的顺序执行,从而防止处理器或编译器对这些操作进行重排序。

内存屏障在多线程或多核处理器环境下至关重要,它确保了某些内存操作在执行时不会被处理器或编译器重新排序,从而避免了数据不一致的问题。

我们以下面的一个例子进行理解:

var x int

// 线程 A
x = 10    // 写操作
// 内存屏障
// 线程 B 可以安全地读取到最新的 x 值

在上面的例子中,如果没有内存屏障,处理器可能会将x = 10的写操作延迟,导致线程B读取到旧值。

有了内存屏障,处理器会确保x = 10的写操作在内存屏障之前完成,使得线程B能够读取到正确的值。

也就是说,内存屏障其实就像在这里打了一个强制断点,只有前面的完成了,才会执行后面的。


内存屏障的作用及类型

在上文我们提到,内存屏障就是为了禁止处理器对于指令的重排序,确保在某些情况下,内存操作按照我们制定的顺序执行。根据内存屏障的类型,它可以阻止以下几种重排序:

  • 读-读屏障:确保读在读前完成

  • 读-写屏障:确保读在写前完成

  • 写-读屏障:确保写在读前完成

  • 写-写屏障:确保写在写前完成

总的来说,就是确保实际执行是按照我们的期望进行的,不要对操作进行重新排序。

对于内存屏障类型,则通常分为以下几类:

  • 全局屏障:阻止所有类型的重排序。确保屏障之前的所有操作,在屏障之后的所有操作之前完成。

  • 加载屏障:阻止加载操作的重排序。确保屏障之前的所有读取操作,在屏障之后的所有读取之前完成。

  • 存储屏障:阻止存储操作的重排序。确保屏障之前的所有写入操作,在屏障之后的所有写入之前完成。

  • 编译器屏障:编译器屏障指令告诉编译器不要重新排序或优化代码块。也就是在编译的时候,确保编译器生成的机器代码保持原定的顺序。

几种类型其实都遵循一个原则,就是保持既定顺序,在这一基础上对于不同的操作进行了细分。


内存屏障在Go中的应用

Go语言中,内存屏障与并发操作有关,例如在实现sync/atomic包的原子操作时就会使用到。如下代码所示:

var (
    sharedVar int32
)

func writer() {
    atomic.StoreInt32(&sharedVar, 42) // 写操作,带有内存屏障
}

func reader() {
    val := atomic.LoadInt32(&sharedVar) // 读操作,带有内存屏障
    fmt.Printf("sharedVar-> %d\n", val)
}

在上面的代码中,atomic.StoreInt32atomic.LoadInt32通过内存屏障确保了sharedVar的写入和读取操作不会被重排序,确保了 reader函数在读取时能够看到writer函数写入的最新值。

常见的并发安全问题

常见问题

Go语言中,并发安全问题与其他语言也大致类似,这也是并发的通用问题,主要会出现以下几种情况:

  • 数据竞争:多个goroutine竞争访问同一个共享变量。

  • 死锁:两个或多个goroutine相互等待对方释放资源,导致程序无法继续执行。

  • 活锁:goroutine持续改变状态但无法前进,类似于死锁,但系统状态不断在变化。

  • 饥饿:某些goroutine长时间得不到执行的机会,导致无法获得必要的资源。


解决方案

解决方案我们在上文中也提到并且编写了示例,我们做一个简单的总结如下:

  • 正确使用锁:使用sync.Mutexsync.RWMutex保护共享数据,但要注意小心死锁问题。

  • 使用通道:用通道传递数据,而不是直接共享内存,这也是官方的推荐做法。

  • 避免复杂的锁组合:减少复杂的锁组合以降低死锁风险,一般来说在一个程序中不适合使用太多锁,代码量增大后还是比较不好维护的。

小结

本节我们讲解了内存模型、数据竞争以及内存屏障的知识。

关于本节总结如下:

  • 内存模型就是一组规则,用于确保并发环境对共享内存的操作顺序、一致性及可见性

  • 数据竞争就是多个协程对同一个内存在无任何措施的情况下进行操作

  • 可以使用锁、通道、原子操作等方式解决数据竞争的问题

  • 内存屏障保证了操作顺序,避免操作顺序被重排

results matching ""

    No results matching ""